Типы данных в C++
Типы данных C++
В языке C++ тип данных — это фундаментальное понятие, определяющее множество допустимых значений и операции, применимые к этим значениям, а также способ их представления в памяти. Система типов в C++ строго статична: каждая переменная, выражение и функция имеют тип, который устанавливается на этапе компиляции и не может быть изменён в ходе выполнения программы. Такой подход обеспечивает высокую производительность, предсказуемость и возможность тщательного контроля со стороны компилятора, что особенно важно в системном программировании и в задачах, где критичны ресурсы и детерминированность поведения.
Статическая типизация в C++ означает, что совместимость типов проверяется до запуска программы. Попытка выполнить операцию над несовместимыми типами без явного указания на допустимость такой операции приводит к ошибке компиляции. Это отличает C++ от языков с динамической типизацией, где проверка типов происходит в процессе выполнения и ошибки могут проявиться не сразу. В C++ также не предусмотрено неявное преобразование между фундаментально различными типами — например, невозможно присвоить строку целочисленной переменной или использовать указатель как булево значение, если только программист не выполнит явное приведение типов.
Система типов в C++ делится на две большие категории: фундаментальные (встроенные) типы и производные (составные) типы. Фундаментальные типы заданы самим языком и реализуются непосредственно средствами аппаратуры или компилятора. Производные типы создаются пользователем на основе фундаментальных и включают в себя такие конструкции, как структуры, классы, массивы, указатели и другие абстракции, позволяющие моделировать сложные сущности и отношения.
Фундаментальные типы данных
Фундаментальные типы — это базис, из которого строятся все остальные типы в программе. Они классифицируются по семантическому назначению: логические, символьные, целочисленные, вещественные и служебные. Размер и точное поведение большинства из них зависят от реализации компилятора и целевой архитектуры, однако стандарт C++ устанавливает строгие минимальные требования и гарантии относительных размеров.
Логический тип bool
Тип bool предназначен для представления логических значений — истина и ложь. В языке C++, начиная со стандарта C++98, bool является полноценным фундаментальным типом. Он принимает ровно два значения: true и false, которые не являются идентификаторами или макросами, а встроены в язык на уровне лексики.
Объявление переменной:
bool <имя>;
Инициализация переменной:
bool <имя> = <значение>;
где <значение> — это true или false.
Присваивание значения:
<имя> = <значение>;
Использование в условии:
if (<имя>) { /* действия */ }
Значение true интерпретируется как 1, false — как 0 при неявных преобразованиях в целочисленные типы, однако внутри программы рекомендуется использовать именно логические литералы, чтобы подчеркнуть намерение и повысить читаемость кода.
#include <iostream>
int main() {
bool isReady = true;
bool isError = false;
std::cout << "isReady: " << isReady << '\n'; // Выведет: 1
std::cout << "isError: " << isError << '\n'; // Выведет: 0
if (isReady) {
std::cout << "Система готова к работе." << std::endl;
}
return 0;
}
Здесь переменные инициализируются явными логическими значениями.
Важно понимать, что в C++ любое скалярное значение может быть неявно преобразовано к bool: нулевые значения всех арифметических типов, нулевые указатели и пустые объекты считаются ложными, все остальные — истинными. Это поведение часто используется в условиях, но при проектировании интерфейсов стоит избегать чрезмерного полагания на такое неявное преобразование, особенно в публичных API, поскольку оно может скрывать логические ошибки.
Неявное преобразование арифметических типов к bool:
#include <iostream>
int main() {
int x = 5;
int y = 0;
double z = -3.14;
char c = '\0';
bool b1 = x; // true (ненулевое значение)
bool b2 = y; // false (нулевое значение)
bool b3 = z; // true (ненулевое значение)
bool b4 = c; // false (нулевой символ)
std::cout << "b1: " << b1 << '\n'; // 1
std::cout << "b2: " << b2 << '\n'; // 0
std::cout << "b3: " << b3 << '\n'; // 1
std::cout << "b4: " << b4 << '\n'; // 0
return 0;
}
Любое ненулевое скалярное значение становится true, ноль — false.
Использование в условных выражениях:
#include <iostream>
#include <vector>
int main() {
int value = 42;
int* ptr = nullptr;
std::vector<int> emptyVec;
std::vector<int> filledVec = {1, 2, 3};
if (value) {
std::cout << "value is truthy\n"; // выполнится
}
if (!ptr) {
std::cout << "ptr is null\n"; // выполнится
}
if (emptyVec.empty()) {
std::cout << "vector is empty\n"; // предпочтительный способ проверки
}
if (filledVec.size()) {
std::cout << "vector has elements (но лучше использовать !vec.empty())\n";
}
return 0;
}
Хотя контейнеры не преобразуются напрямую к bool, их методы (например, .empty()) возвращают bool. Рекомендуется использовать такие методы вместо неявного преобразования размера к логическому значению.
Функции, возвращающие bool:
bool isEven(int n) {
return n % 2 == 0;
}
bool isValidPointer(const void* p) {
return p != nullptr;
}
int main() {
if (isEven(10)) {
std::cout << "10 is even\n";
}
int* p = nullptr;
if (!isValidPointer(p)) {
std::cout << "Pointer is invalid\n";
}
return 0;
}
Функции, возвращающие bool, улучшают читаемость условий. Имена таких функций часто начинаются с is, has, can, should.
Символьные типы: char, wchar_t, char16_t, char32_t
Тип char служит для представления отдельного символа.
Объявление переменной:
<тип> <имя>;
где <тип> — один из символьных типов (char, wchar_t и т.д.).
Инициализация символом:
<тип> <имя> = '<символ>';
Инициализация числовым кодом:
<тип> <имя> = <число>;
Присваивание значения:
<имя> = '<символ>';
или
<имя> = <число>;
Использование в арифметике:
int <результат> = <имя> + <число>;
По историческим причинам и для совместимости с C, char имеет размер ровно 1 байт — это не означает, что char всегда кодирует один символ в смысле текстового представления.
#include <iostream>
int main() {
char letter = 'A';
char digit = '7';
char symbol = '@';
std::cout << "Буква: " << letter << std::endl; // Вывод: A
std::cout << "Цифра: " << digit << std::endl; // Вывод: 7
std::cout << "Символ: " << symbol << std::endl; // Вывод: @
// Символ можно использовать в арифметике
char nextLetter = letter + 1; // 'B'
std::cout << "Следующая буква: " << nextLetter << std::endl;
return 0;
}
Тип char может участвовать в арифметических операциях, поскольку каждый символ имеет числовой код (например, в кодировке ASCII).
На практике char используется для хранения как ASCII-символов, так и байтов произвольных данных — например, двоичных буферов или UTF-8-кодированных последовательностей. Поскольку char может быть как знаковым, так и беззнаковым в зависимости от компилятора и платформы, его нельзя однозначно использовать для арифметических операций без приведения; для этого следует предпочесть signed char или unsigned char, если необходима гарантия знаковости.
Типы wchar_t, char16_t и char32_t были введены для повышения переносимости при работе с многобайтовыми кодировками. Тип wchar_t появился в C++98 и исторически использовался для «широких» символов, однако его размер и семантика сильно варьировались: 2 байта в Windows (для UTF-16), 4 байта в большинстве Unix-систем (для UTF-32). Чтобы устранить эту неопределённость, стандарт C++11 ввёл char16_t (гарантированно 2 байта, предназначен для UTF-16) и char32_t (гарантированно 4 байта, для UTF-32), а также соответствующие строковые литералы: u"…", U"…", L"…". Сегодня рекомендуется избегать wchar_t в кроссплатформенном коде и использовать char с UTF-8 или char16_t/char32_t при явной необходимости работы с конкретной кодировкой.
Размеры символьных типов:
#include <iostream>
int main() {
std::cout << "sizeof(char) = " << sizeof(char) << " byte\n";
std::cout << "sizeof(wchar_t) = " << sizeof(wchar_t) << " bytes\n";
std::cout << "sizeof(char16_t) = " << sizeof(char16_t) << " bytes\n";
std::cout << "sizeof(char32_t) = " << sizeof(char32_t) << " bytes\n";
return 0;
}
На большинстве Unix-систем (например, Linux/macOS) вывод будет:
sizeof(char) = 1 byte
sizeof(wchar_t) = 4 bytes
sizeof(char16_t) = 2 bytes
sizeof(char32_t) = 4 bytes
На Windows:
sizeof(char) = 1 byte
sizeof(wchar_t) = 2 bytes
sizeof(char16_t) = 2 bytes
sizeof(char32_t) = 4 bytes
Это показывает, почему wchar_t не подходит для кроссплатформенного кода: его размер зависит от ОС.
Использование char для ASCII и UTF-8:
#include <iostream>
#include <string>
int main() {
char c1 = 'A'; // ASCII-символ
char c2 = '\n'; // Управляющий символ
char c3 = 0x41; // То же, что 'A'
const char* ascii_str = "Hello";
const char* utf8_str = u8"Привет"; // UTF-8 строка (C++11+)
std::cout << "c1 = " << c1 << '\n';
std::cout << "ASCII: " << ascii_str << '\n';
std::cout << "UTF-8: " << utf8_str << '\n';
// char может хранить байты любого происхождения
unsigned char buffer[] = {0xDE, 0xAD, 0xBE, 0xEF};
for (auto b : buffer) {
std::cout << std::hex << static_cast<int>(b) << ' ';
}
std::cout << '\n';
return 0;
}
Здесь char используется как для текста (включая UTF-8), так и для неинтерпретируемых данных. Обратите внимание: при работе с бинарными данными предпочтительнее unsigned char, чтобы избежать неопределённости со знаком.
Использование char16_t и char32_t:
#include <iostream>
#include <string>
int main() {
char16_t c16 = u'λ'; // UTF-16 символ
char32_t c32 = U'🙂'; // UTF-32 символ (эмодзи)
std::u16string str16 = u"Привет 🌍";
std::u32string str32 = U"Здравствуй, Вселенная! 🚀";
// Прямой вывод в std::cout невозможен — нет стандартного оператора <<
// Но можно вывести числовые значения:
std::cout << "char16_t value: 0x" << std::hex << static_cast<uint16_t>(c16) << '\n';
std::cout << "char32_t value: 0x" << std::hex << static_cast<uint32_t>(c32) << '\n';
// Или длины строк:
std::cout << "u16string length: " << str16.size() << " code units\n";
std::cout << "u32string length: " << str32.size() << " code points\n";
return 0;
}
Типы char16_t и char32_t имеют чётко определённый размер и предназначены для работы с UTF-16 и UTF-32 соответственно. Однако стандартная библиотека C++ не предоставляет удобных средств для их вывода в консоль без дополнительной обработки (например, преобразования в UTF-8).
Преобразование между типами (простой пример):
#include <iostream>
int main() {
char c = 'X';
wchar_t wc = static_cast<wchar_t>(c); // Расширение до широкого символа
char16_t c16 = static_cast<char16_t>(c);
char32_t c32 = static_cast<char32_t>(c);
std::cout << "Original: " << c << '\n';
std::cout << "As wchar_t: " << static_cast<int>(wc) << '\n';
std::cout << "As char16_t: " << static_cast<uint16_t>(c16) << '\n';
std::cout << "As char32_t: " << static_cast<uint32_t>(c32) << '\n';
return 0;
}
Прямые приведения допустимы, но теряют смысл при работе с не-ASCII символами. Для корректной транскодировки используются специализированные средства (std::wstring_convert, ICU, или сторонние библиотеки).
Целочисленные типы: short, int, long, long long
Целочисленные типы предназначены для хранения значений без дробной части.
Объявление переменной:
<модификатор> <тип> <имя>;
где <модификатор> — необязательное слово unsigned,
а <тип> — один из: short, int, long, long long.
Инициализация переменной:
<тип> <имя> = <целое_число>;
Присваивание значения:
<имя> = <целое_число>;
Арифметическая операция:
<тип> <результат> = <имя1> <оператор> <имя2>;
где <оператор> — один из: +, -, *, /, %.
Инкремент/декремент:
<имя>++;
--<имя>;
В C++ они образуют иерархию, в которой каждый следующий тип не короче предыдущего:
short(илиshort int) — короткое целое, как минимум 16 битint— целое, как минимум столько же бит, сколькоshort, и обычно соответствует «естественному» размеру слова процессора (на 32- и 64-битных платформах — 32 бита)long(илиlong int) — длинное целое, как минимум 32 бита, не корочеintlong long(илиlong long int) — расширенное длинное целое, введено в C++11, как минимум 64 бита
Целочисленные типы хранят числа без дробной части. Они различаются по диапазону допустимых значений и размеру в памяти.
#include <iostream>
int main() {
short smallNumber = 32767;
int usualNumber = 1000000;
long bigNumber = 2147483647L;
long long hugeNumber = 9223372036854775807LL;
std::cout << "short: " << smallNumber << std::endl;
std::cout << "int: " << usualNumber << std::endl;
std::cout << "long: " << bigNumber << std::endl;
std::cout << "long long: " << hugeNumber << std::endl;
// Отрицательные значения также допустимы
int negative = -42;
std::cout << "Отрицательное число: " << negative << std::endl;
return 0;
}
Точный размер этих типов не фиксирован стандартом и зависит от реализации. Например, на большинстве современных x86-64 систем под Linux и Windows:
short= 2 байтаint= 4 байтаlong= 4 байта (Windows) или 8 байт (Linux/macOS)long long= 8 байт везде
Для написания переносимого кода, где важен точный размер, следует использовать фиксированные целочисленные типы из заголовка <cstdint>: int8_t, uint16_t, int32_t, uint64_t и другие. Однако при повседневной разработке предпочтение отдаётся int, если не требуется особая ёмкость или битовая точность — он наиболее эффективен и интуитивно понятен.
Размеры встроенных целочисленных типов:
#include <iostream>
#include <climits>
int main() {
std::cout << "sizeof(short) = " << sizeof(short) << " bytes (" << CHAR_BIT * sizeof(short) << " bits)\n";
std::cout << "sizeof(int) = " << sizeof(int) << " bytes (" << CHAR_BIT * sizeof(int) << " bits)\n";
std::cout << "sizeof(long) = " << sizeof(long) << " bytes (" << CHAR_BIT * sizeof(long) << " bits)\n";
std::cout << "sizeof(long long) = " << sizeof(long long) << " bytes (" << CHAR_BIT * sizeof(long long) << " bits)\n";
// Минимальные гарантии стандарта:
std::cout << "\nГарантированный минимум:\n";
std::cout << "short: >= 16 bits\n";
std::cout << "int: >= 16 bits (но обычно 32)\n";
std::cout << "long: >= 32 bits\n";
std::cout << "long long: >= 64 bits\n";
return 0;
}
Этот код показывает реальные размеры на текущей платформе. Обратите внимание: long может быть 4 байта (Windows) или 8 байт (Linux/macOS), что делает его непереносимым для задач, требующих точного размера.
Целочисленные типы могут быть знаковыми (signed) или беззнаковыми (unsigned). По умолчанию типы short, int, long, long long считаются знаковыми, то есть способны хранить как положительные, так и отрицательные значения, используя дополнительный код. Беззнаковая версия (например, unsigned int) расширяет диапазон положительных значений за счёт невозможности представления отрицательных чисел. Арифметические операции с беззнаковыми типами выполняются по модулю 2N, где N — количество бит; это гарантирует отсутствие неопределённого поведения при переполнении, в отличие от знаковых типов, где переполнение — неопределённое поведение (undefined behavior), и компилятор имеет право оптимизировать код, исходя из предположения, что оно не происходит.
Знаковые и беззнаковые типы:
#include <iostream>
#include <cstdint>
int main() {
signed int si = -42;
unsigned int ui = 4294967254U; // большое положительное число
std::cout << "signed int: " << si << '\n';
std::cout << "unsigned int: " << ui << '\n';
// Беззнаковый тип не может быть отрицательным:
unsigned int u = -1; // Не ошибка! Результат — максимальное значение типа
std::cout << "unsigned int u = -1 → " << u << '\n'; // Выведет 4294967295 на 32-битной системе
return 0;
}
Здесь демонстрируется, что присваивание отрицательного значения беззнаковому типу приводит к «оборачиванию» по модулю (2^N). Это определённое поведение, в отличие от переполнения знакового типа.
Переполнение:
#include <iostream>
#include <climits>
int main() {
// Беззнаковое переполнение — определено стандартом
unsigned int max_ui = UINT_MAX;
unsigned int wrapped = max_ui + 1;
std::cout << "UINT_MAX + 1 = " << wrapped << " (ожидаемо 0)\n";
// Знаковое переполнение — неопределённое поведение!
// Следующий код формально некорректен:
int max_i = INT_MAX;
int overflowed = max_i + 1; // Не делайте так!
// На практике компилятор может оптимизировать, исходя из предположения,
// что такого не произойдёт. Результат непредсказуем.
std::cout << "INT_MAX + 1 = " << overflowed << " (поведение не определено!)\n";
return 0;
}
Беззнаковая арифметика безопасна с точки зрения стандарта: результат всегда предсказуем. Знаковая арифметика при переполнении вызывает неопределённое поведение, что может привести к ошибкам, которые трудно отловить.
Использование фиксированных типов из <cstdint>:
#include <iostream>
#include <cstdint>
int main() {
std::int8_t i8 = -128; // точно 8 бит, знаковый
std::uint16_t u16 = 65535; // точно 16 бит, беззнаковый
std::int32_t i32 = 2147483647;
std::uint64_t u64 = 18446744073709551615ULL;
std::cout << "int8_t: " << static_cast<int>(i8) << '\n';
std::cout << "uint16_t: " << u16 << '\n';
std::cout << "int32_t: " << i32 << '\n';
std::cout << "uint64_t: " << u64 << '\n';
// Эти типы особенно полезны при работе с:
// - сетевыми протоколами
// - двоичными форматами файлов
// - аппаратными регистрами
// - кроссплатформенными структурами данных
return 0;
}
Фиксированные типы обеспечивают переносимость. Если требуется именно 32-битное целое, используйте std::int32_t, а не int или long.
Когда использовать int, а когда фиксированные типы?
#include <vector>
#include <cstdint>
// Пример 1: индексация контейнеров — используем size_t или int
void processVector(const std::vector<double>& vec) {
for (int i = 0; i < static_cast<int>(vec.size()); ++i) {
// Допустимо, если размер вектора умеренный
}
}
// Пример 2: работа с бинарным протоколом — нужен точный размер
struct NetworkHeader {
std::uint32_t magic; // 4 байта
std::uint16_t version; // 2 байта
std::uint16_t payload_len; // 2 байта
};
// Пример 3: математические вычисления — часто достаточно int
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int main() {
auto header = NetworkHeader{0xDEADBEEF, 1, 1024};
std::cout << "Magic: 0x" << std::hex << header.magic << '\n';
std::cout << "Factorial(5): " << std::dec << factorial(5) << '\n';
return 0;
}
Правило:
- Используйте
intдля счётчиков, индексов, простых вычислений. - Используйте
std::intN_t/std::uintN_tпри взаимодействии с внешними системами, где важен точный размер. - Используйте
size_tдля размеров и индексов контейнеров (хотя при небольших размерах допустимо иintс приведением).
Вещественные типы: float, double
Типы с плавающей точкой предназначены для приближённого представления действительных чисел.
Объявление переменной:
<тип> <имя>;
где <тип> — float, double или long double.
Инициализация переменной:
<тип> <имя> = <вещественное_число><суффикс>;
Суффикс обязателен только для float (f или F). Для double и long double суффикс не требуется, но может быть указан как L для long double.
Присваивание значения:
<имя> = <вещественное_число><суффикс>;
Арифметическая операция:
<тип> <результат> = <имя1> <оператор> <имя2>;
где <оператор> — один из: +, -, *, /.
Использование математической функции:
<тип> <результат> = <функция>(<имя>);
например: double x = sqrt(value);
В C++ поддерживаются три таких типа:
float— одинарная точность, обычно соответствует 32-битному формату IEEE 754: 1 бит знака, 8 бит порядка, 23 бита мантиссыdouble— двойная точность, обычно 64-битный IEEE 754: 1 + 11 + 52 битаlong double— расширенная точность, размер и формат зависят от платформы: 80-битный (x87), 128-битный (IEEE 754 quadruple) или просто алиас дляdouble
Вещественные типы предназначены для хранения чисел с дробной частью. Они применяются при вычислениях, требующих представления десятичных дробей.
#include <iostream>
#include <iomanip> // для управления точностью вывода
int main() {
float price = 19.99f;
double pi = 3.141592653589793;
long double preciseValue = 1.234567890123456789L;
// Установка точности вывода
std::cout << std::fixed << std::setprecision(2);
std::cout << "Цена: " << price << " руб." << std::endl;
std::cout << std::setprecision(10);
std::cout << "Число Пи: " << pi << std::endl;
std::cout << std::setprecision(15);
std::cout << "Точное значение: " << preciseValue << std::endl;
return 0;
}
Литералы типа float помечаются суффиксом f или F. Тип double используется по умолчанию для вещественных литералов. Тип long double обеспечивает наибольшую точность среди вещественных типов, хотя его фактическая реализация зависит от компилятора и платформы.
Ключевое свойство вещественных типов — конечная точность. Не все десятичные дроби могут быть точно представлены в двоичной системе; например, значение 0.1 хранится лишь приближённо. Вследствие этого сравнение вещественных чисел на равенство через == обычно небезопасно — следует использовать сравнение с допуском («эпсилон»), либо пересмотреть логику программы так, чтобы избежать прямого сравнения.
Операции с плавающей точкой подчиняются стандарту IEEE 754, если реализация его поддерживает, что обеспечивает совместимость и предсказуемость: определены значения +∞, −∞, NaN («не число»), а также поведение при делении на ноль, переполнении и денормализованных числах. Однако важно помнить, что выполнение операций с плавающей точкой не ассоциативно: (a + b) + c может отличаться от a + (b + c) из-за накопления погрешностей. Этот факт критичен при численных расчётах и параллельных вычислениях.
Неточное представление десятичных дробей:
#include <iostream>
#include <iomanip>
int main() {
float f = 0.1f;
double d = 0.1;
std::cout << std::setprecision(17);
std::cout << "float 0.1 ≈ " << f << '\n';
std::cout << "double 0.1 ≈ " << d << '\n';
// Сумма 0.1 десять раз не даёт ровно 1.0
double sum = 0.0;
for (int i = 0; i < 10; ++i) {
sum += 0.1;
}
std::cout << "Сумма десяти 0.1 = " << sum << '\n';
std::cout << "Равно ли 1.0? " << (sum == 1.0 ? "да" : "нет") << '\n';
return 0;
}
Этот пример показывает, что даже простая дробь 0.1 не может быть точно представлена в двоичной системе. Сумма десяти таких приближений не равна 1.0 в точности.
Безопасное сравнение с использованием эпсилона:
#include <iostream>
#include <cmath>
bool nearlyEqual(double a, double b, double epsilon = 1e-9) {
return std::abs(a - b) < epsilon;
}
int main() {
double x = 0.1 + 0.2;
double y = 0.3;
std::cout << "x = " << x << '\n';
std::cout << "y = " << y << '\n';
std::cout << "x == y? " << (x == y ? "да" : "нет") << '\n';
std::cout << "nearlyEqual(x, y)? " << (nearlyEqual(x, y) ? "да" : "нет") << '\n';
// Для больших чисел нужен относительный эпсилон
double a = 1e10;
double b = a + 1.0;
std::cout << "\nБольшие числа:\n";
std::cout << "a = " << a << ", b = " << b << '\n';
std::cout << "Абсолютное сравнение: " << (nearlyEqual(a, b) ? "равны" : "не равны") << '\n';
// Относительное сравнение
bool relEqual = std::abs(a - b) <= 1e-9 * std::max(std::abs(a), std::abs(b));
std::cout << "Относительное сравнение: " << (relEqual ? "равны" : "не равны") << '\n';
return 0;
}
Прямое сравнение через == ненадёжно. Используйте абсолютную или относительную погрешность в зависимости от масштаба чисел.
Специальные значения (бесконечность и NaN):
#include <iostream>
#include <cmath>
#include <limits>
int main() {
double inf = std::numeric_limits<double>::infinity();
double nan = std::numeric_limits<double>::quiet_NaN();
std::cout << "inf = " << inf << '\n';
std::cout << "nan = " << nan << '\n';
// Арифметика с бесконечностью
std::cout << "inf + 1 = " << inf + 1 << '\n';
std::cout << "inf * -1 = " << inf * -1 << '\n';
std::cout << "inf - inf = " << inf - inf << " (получаем NaN)\n";
// Проверка на NaN
std::cout << "isnan(nan) = " << std::isnan(nan) << '\n';
std::cout << "isnan(inf) = " << std::isnan(inf) << '\n';
// Важно: NaN != NaN всегда!
std::cout << "nan == nan → " << (nan == nan ? "true" : "false") << '\n';
std::cout << "isnan(nan) — правильный способ проверки\n";
// Деление на ноль
double zero = 0.0;
std::cout << "1.0 / 0.0 = " << 1.0 / zero << '\n';
std::cout << "-1.0 / 0.0 = " << -1.0 / zero << '\n';
return 0;
}
Стандарт IEEE 754 определяет поведение при исключительных ситуациях. Бесконечность и NaN — часть нормальной арифметики с плавающей точкой.
Неассоциативность сложения:
#include <iostream>
#include <iomanip>
int main() {
double a = 1e20;
double b = -1e20;
double c = 1.0;
double left_to_right = (a + b) + c; // (1e20 - 1e20) + 1 = 0 + 1 = 1
double right_to_left = a + (b + c); // 1e20 + (-1e20 + 1) = 1e20 + (-1e20) = 0
std::cout << std::setprecision(17);
std::cout << "(a + b) + c = " << left_to_right << '\n';
std::cout << "a + (b + c) = " << right_to_left << '\n';
std::cout << "Равны? " << (left_to_right == right_to_left ? "да" : "нет") << '\n';
return 0;
}
Из-за ограниченной точности порядок операций влияет на результат. Это критично при параллельных вычислениях или рефакторинге выражений.
Размеры и представление типов с плавающей точкой:
#include <iostream>
#include <limits>
int main() {
std::cout << "sizeof(float) = " << sizeof(float) << " bytes\n";
std::cout << "sizeof(double) = " << sizeof(double) << " bytes\n";
std::cout << "sizeof(long double) = " << sizeof(long double) << " bytes\n";
std::cout << "\nДиапазоны:\n";
std::cout << "float: [" << std::numeric_limits<float>::min() << ", "
<< std::numeric_limits<float>::max() << "]\n";
std::cout << "double: [" << std::numeric_limits<double>::min() << ", "
<< std::numeric_limits<double>::max() << "]\n";
std::cout << "\nТочность (количество десятичных цифр):\n";
std::cout << "float: " << std::numeric_limits<float>::digits10 << '\n';
std::cout << "double: " << std::numeric_limits<double>::digits10 << '\n';
return 0;
}
На большинстве платформ:
float— ~7 десятичных цифр точностиdouble— ~15–17 десятичных цифр
long double может быть 80-битным (x86), 128-битным (SPARC) или просто синонимом double (MSVC).
Когда использовать float, а когда double?
#include <vector>
#include <cmath>
// Используем float для экономии памяти в больших массивах
void processLargeDataset() {
const size_t N = 10'000'000;
std::vector<float> data(N); // 40 МБ вместо 80 МБ при double
for (size_t i = 0; i < N; ++i) {
data[i] = std::sin(static_cast<float>(i) * 0.001f);
}
// Подходит для графики, ML, обработки сигналов
}
// Используем double для высокой точности
double computeFinancialValue(double principal, double rate, int years) {
return principal * std::pow(1.0 + rate, years);
// Финансы, научные расчёты — требуют double
}
int main() {
processLargeDataset();
double result = computeFinancialValue(1000.0, 0.05, 10);
std::cout << "Итоговая сумма: " << result << '\n';
return 0;
}
Правило:
- Используйте
doubleпо умолчанию — он точнее и часто не медленнее на современных CPU. - Используйте
floatтолько при ограничениях памяти или при работе с API, требующим одинарной точности (например, OpenGL, Vulkan). - Избегайте
long double, если не требуется максимальная точность и вы не контролируете платформу.
Служебный тип void
Тип void — особый случай. Он означает отсутствие значения и не может быть использован для объявления переменных. Однако он имеет важное применение:
- как возвращаемый тип функции — указывает, что функция ничего не возвращает
- в указателях —
void*обозначает указатель на «некий» объект неизвестного типа; такой указатель может быть преобразован в любой другой указатель на объект, но не на функцию - в параметрах функции (в C++ — избыточно) —
void f(void)эквивалентноvoid f(), в отличие от C, где первая форма означает «ровно ноль аргументов», а вторая — «неизвестное число аргументов»
void играет роль «нулевого элемента» в системе типов и часто используется в обобщённом программировании и метапрограммировании для обозначения отсутствия результата.
void как возвращаемый тип функции:
#include <iostream>
// Функция ничего не возвращает — тип void
void logMessage(const char* msg) {
std::cout << "[LOG] " << msg << '\n';
}
// Такая функция не может вернуть значение
// return 42; // Ошибка компиляции!
int main() {
logMessage("Программа запущена");
logMessage("Завершение работы");
return 0;
}
Функции с возвращаемым типом void используются для выполнения побочных эффектов: вывода, изменения состояния, записи в файл и т.п.
Указатель void* — универсальный указатель на данные:
#include <iostream>
#include <cstdint>
int main() {
int number = 42;
double pi = 3.14159;
char text[] = "Hello";
// void* может указывать на любой объект
void* ptr1 = &number;
void* ptr2 = π
void* ptr3 = text; // массив превращается в указатель
// Но из void* нельзя напрямую прочитать значение — нужен явный каст
std::cout << "number через void*: " << *static_cast<int*>(ptr1) << '\n';
std::cout << "pi через void*: " << *static_cast<double*>(ptr2) << '\n';
std::cout << "text через void*: " << static_cast<char*>(ptr3) << '\n';
// void* нельзя разыменовать напрямую:
// std::cout << *ptr1; // Ошибка компиляции!
// void* не может указывать на функцию (без reinterpret_cast)
// void (*f)() = nullptr;
// void* p = f; // Ошибка! Нестандартное преобразование
return 0;
}
void* часто используется в низкоуровневом коде: аллокаторах памяти (malloc в C), callback-интерфейсах, сериализации. В современном C++ его применение сокращается в пользу типобезопасных альтернатив (std::any, std::variant, шаблонов).
Параметры функции:
// В C++ эти две декларации эквивалентны
void func1();
void func2(void);
// Обе означают: функция принимает ровно ноль аргументов
void func1() {
// тело
}
void func2(void) {
// тело
}
int main() {
func1(); // корректно
func2(); // корректно
return 0;
}
В отличие от C, где void f(); означает «количество и типы аргументов неизвестны», в C++ обе формы строго означают «нет параметров». Стиль f(void) считается избыточным и редко используется в C++.
void в обобщённом программировании:
#include <type_traits>
#include <iostream>
// Шаблонная функция, которая может возвращать что угодно — или ничего
template<typename T>
T getValue();
// Специализация для void: функция просто завершается
template<>
void getValue<void>() {
std::cout << "Специализация для void: ничего не возвращаем\n";
}
// Пример использования в условной логике
template<typename Func>
auto callAndReport(Func f) -> decltype(f()) {
std::cout << "Вызываем функцию с возвратом...\n";
return f();
}
template<typename Func>
void callAndReport(Func f, std::void_t<decltype(f())>* = nullptr) {
// Перегрузка для случая, когда f() возвращает void
std::cout << "Вызываем функцию без возврата...\n";
f();
}
void sayHello() {
std::cout << "Привет!\n";
}
int getNumber() {
return 123;
}
int main() {
getValue<void>(); // вызывает специализацию
getValue<int>(); // ошибка линковки, но демонстрирует идею
callAndReport(sayHello); // выбирает перегрузку для void
callAndReport(getNumber); // выбирает перегрузку с возвратом
return 0;
}
Хотя void не может быть типом переменной, он легально используется в контексте типов: как аргумент шаблона, в decltype, в метафункциях (std::is_void_v<T>). Это позволяет писать универсальные алгоритмы, которые корректно обрабатывают как функции с возвратом, так и без.
Проверка типа void с помощью type traits:
#include <type_traits>
#include <iostream>
template<typename T>
void inspectType() {
if constexpr (std::is_void_v<T>) {
std::cout << "Тип — void\n";
} else {
std::cout << "Тип — не void\n";
}
}
int main() {
inspectType<void>(); // Тип — void
inspectType<int>(); // Тип — не void
return 0;
}
Это полезно при написании библиотечного кода, где поведение должно различаться в зависимости от того, возвращает ли вызываемый объект значение или нет.
Квалификаторы и спецификаторы типов
Помимо базовых фундаментальных типов, C++ предоставляет набор спецификаторов, влияющих на интерпретацию, изменяемость и поведение данных.
signed и unsigned
Эти спецификаторы применимы к любым целочисленным типам, включая char. Они уточняют, должен ли тип интерпретироваться как знаковый или беззнаковый. Например, unsigned int, signed char. Отдельно стоит отметить, что char, signed char и unsigned char — это три различных типа с разными свойствами, несмотря на то, что char может совпадать по представлению с одним из них. Это особенно важно при перегрузке функций и специализации шаблонов.
#include <iostream>
#include <type_traits>
void handleChar(char c) {
std::cout << "char: " << static_cast<int>(c) << '\n';
}
void handleSignedChar(signed char c) {
std::cout << "signed char: " << static_cast<int>(c) << '\n';
}
void handleUnsignedChar(unsigned char c) {
std::cout << "unsigned char: " << static_cast<int>(c) << '\n';
}
int main() {
// Эти три вызова могут вести к разным перегрузкам!
char a = -1;
signed char b = -1;
unsigned char c = 255; // эквивалентно -1 в signed, но не в unsigned
handleChar(a); // вызывает handleChar
handleSignedChar(b); // вызывает handleSignedChar
handleUnsignedChar(c); // вызывает handleUnsignedChar
// Проверка различий на уровне типов
std::cout << "\nТипы различны:\n";
std::cout << "char == signed char? " << std::is_same_v<char, signed char> << '\n';
std::cout << "char == unsigned char? " << std::is_same_v<char, unsigned char> << '\n';
return 0;
}
Этот пример показывает, что char, signed char и unsigned char — три независимых типа. Это важно при перегрузке функций и шаблонной специализации.
const
Спецификатор const указывает, что значение, на которое он ссылается, не может быть изменено после инициализации. Он может применяться как к переменным, так и к указателям, ссылкам, параметрам функций, возвращаемым типам и членам классов. Например:
const int x = 5;— значениеxфиксированоint* const p = &x;— указатель константен (адрес нельзя изменить), но значение по адресу — можноconst int* p = &x;— значение константно (нельзя изменить черезp), но указатель — можно переназначитьconst int* const p = &x;— и указатель, и значение неизменяемы
const — мощный инструмент проектирования: он позволяет выразить инварианты, включает оптимизации на уровне компилятора и делает интерфейсы более понятными. В частности, методы класса могут быть помечены как const, что гарантирует отсутствие изменения состояния объекта при их вызове.
Различные формы const с указателями:
#include <iostream>
int main() {
int value = 42;
int another = 100;
// 1. Указатель на константное значение
const int* p1 = &value;
// *p1 = 10; // Ошибка: нельзя изменить значение через p1
p1 = &another; // OK: можно изменить сам указатель
// 2. Константный указатель на изменяемое значение
int* const p2 = &value;
*p2 = 99; // OK: значение можно менять
// p2 = &another; // Ошибка: нельзя изменить адрес указателя
// 3. Константный указатель на константное значение
const int* const p3 = &value;
// *p3 = 50; // Ошибка
// p3 = &another; // Ошибка
std::cout << "value = " << value << '\n'; // 99
std::cout << "*p1 = " << *p1 << '\n'; // 100
std::cout << "*p2 = " << *p2 << '\n'; // 99
std::cout << "*p3 = " << *p3 << '\n'; // 99
return 0;
}
Чтобы запомнить: читайте справа налево.
const int*→ указатель наconst intint* const→constуказатель наint
const-методы и логическая константность:
#include <iostream>
#include <string>
class Counter {
private:
int count_ = 0;
mutable int cache_ = -1; // может меняться даже в const-методах
mutable bool cache_valid_ = false;
public:
void increment() {
++count_;
cache_valid_ = false;
}
// const-метод: не меняет логическое состояние
int getCount() const {
if (!cache_valid_) {
cache_ = count_; // OK: cache_ — mutable
cache_valid_ = true; // OK: mutable
}
return cache_;
}
// const-метод не может вызывать неконстантные методы
// void reset() const { count_ = 0; } // Ошибка!
};
int main() {
const Counter c; // объект объявлен как const
// c.increment(); // Ошибка: increment() не const
std::cout << "Count: " << c.getCount() << '\n'; // OK
Counter d;
d.increment();
std::cout << "Count: " << d.getCount() << '\n'; // OK
return 0;
}
const-методы гарантируют, что они не изменяют наблюдаемое состояние объекта. mutable позволяет обойти это ограничение для служебных полей.
volatile
Спецификатор volatile указывает компилятору, что значение переменной может изменяться внешними по отношению к программе факторами — например, аппаратным прерыванием, другим потоком выполнения или сопроцессором. Это запрещает компилятору выполнять оптимизации, полагающиеся на предсказуемость изменения значения: каждое чтение и запись должны выполняться непосредственно в память, без кэширования в регистрах. volatile не обеспечивает потокобезопасности и не создаёт барьеров памяти — для синхронизации между потоками следует использовать std::atomic, мьютексы и другие средства из <thread> и <atomic>.
Доступ к «изменчивым» данным:
#include <iostream>
// Пример: регистр аппаратного устройства (гипотетический)
volatile int* hardware_register = reinterpret_cast<volatile int*>(0x12345678);
void waitForSignal() {
// Без volatile компилятор мог бы оптимизировать цикл в бесконечный,
// считая, что значение не меняется
while (*hardware_register == 0) {
// ждём, пока регистр не станет ненулевым
}
std::cout << "Сигнал получен!\n";
}
// Пример с обычной переменной (не рекомендуется без причины)
volatile int flag = 0;
void simulateExternalChange() {
// Представим, что другой поток или прерывание меняет flag
// (в реальности для потоков нужно std::atomic!)
flag = 1;
}
int main() {
// В реальном коде volatile используется редко:
// - в embedded-системах
// - при взаимодействии с hardware
// - в signal handlers (ограниченно)
// Для многопоточности volatile НЕ подходит!
simulateExternalChange();
if (flag) {
std::cout << "Флаг установлен\n";
}
return 0;
}
volatile гарантирует, что каждое обращение к переменной будет выполнено как фактическое чтение/запись в память. Он не обеспечивает атомарности и не создаёт барьеров памяти, поэтому не подходит для синхронизации между потоками.
mutable
Этот спецификатор применим только к нестатическим членам класса. Он разрешает изменение такого члена даже внутри const-методов. Обычно mutable используется для вспомогательных полей, не влияющих на логическое состояние объекта — например, кэшей, счётчиков обращений или mutex’ов, защищающих внутреннее состояние в thread-safe классах. Пример:
class Loggable {
mutable std::mutex mtx_;
mutable int access_count_ = 0;
public:
void log() const {
std::lock_guard lock(mtx_); // захват мьютекса в const-методе
++access_count_;
// ... запись в лог
}
};
Здесь изменение access_count_ и mtx_ не нарушает контракт const, поскольку они служат исключительно для реализации, а не для хранения семантически значимого состояния.
mutable в thread-safe классе:
#include <iostream>
#include <mutex>
#include <string>
class ThreadSafeLogger {
private:
mutable std::mutex mtx_; // защищает внутреннее состояние
mutable std::string last_message_; // кэш последнего сообщения
public:
void log(const std::string& msg) const {
// Метод const, но мы можем менять mutable-поля
std::lock_guard<std::mutex> lock(mtx_);
last_message_ = msg;
std::cout << "[LOG] " << msg << '\n';
}
std::string getLastMessage() const {
std::lock_guard<std::mutex> lock(mtx_);
return last_message_;
}
};
int main() {
const ThreadSafeLogger logger;
logger.log("Запуск системы");
logger.log("Обработка данных");
std::cout << "Последнее: " << logger.getLastMessage() << '\n';
return 0;
}
Здесь const-методы обеспечивают логическую константность (состояние «сообщения» семантически не меняется при запросе), но внутренние механизмы (mutex, кэш) могут обновляться благодаря mutable.
Производные типы данных
Если фундаментальные типы — это атомы, то производные типы — молекулы и сложные структуры, составленные из них. В C++ производные типы создаются не введением новых базовых возможностей языка, а комбинированием уже существующих механизмов: указателей, ссылок, агрегации и инкапсуляции. Они позволяют моделировать реальные сущности и отношения, управлять ресурсами, обеспечивать безопасность и выразительность. Все производные типы строятся на основе фундаментальных и друг друга, и каждый из них обладает собственной семантикой копирования, перемещения, инициализации и разрушения.
Строгого формального разделения «производный тип» — «не производный» в стандарте нет, однако в педагогической и инженерной практике к производным принято относить следующие категории: массивы, указатели, ссылки, перечисления (enum), объединения (union), структуры (struct) и классы (class). Важно понимать, что границы между ними подвижны: например, класс может содержать массив, указатель на который возвращается методом, а ссылка на этот указатель используется в шаблоне. Тем не менее, каждая конструкция имеет историческое происхождение, определённое поведение по умолчанию и зону ответственности.
Указатели
Указатель хранит адрес переменной в памяти и позволяет косвенно обращаться к её значению.
#include <iostream>
int main() {
int value = 42;
int* ptr = &value; // ptr содержит адрес переменной value
std::cout << "Значение: " << value << std::endl; // 42
std::cout << "Адрес: " << ptr << std::endl; // например, 0x7fff5fbff6ac
std::cout << "Значение через указатель: " << *ptr << std::endl; // 42
*ptr = 100; // изменяем значение через указатель
std::cout << "Новое значение: " << value << std::endl; // 100
return 0;
}
Указатели также могут быть nullptr, что означает отсутствие адреса:
int* nullPtr = nullptr;
if (nullPtr == nullptr) {
std::cout << "Указатель не ссылается ни на что." << std::endl;
}
Объявление указателя:
<тип>* <имя>;
Инициализация указателя адресом переменной:
<тип>* <имя> = &<переменная>;
Инициализация нулевым указателем:
<тип>* <имя> = nullptr;
Разыменование указателя:
*<имя> = <значение>;
или
<тип> <результат> = *<имя>;
Передача указателя в функцию:
<возвращаемый_тип> <функция>(<тип>* <параметр>);
Указатель — одна из самых фундаментальных и мощных абстракций C++. Это объект, значение которого представляет собой адрес другого объекта (или функции) в памяти. Тип указателя всегда включает в себя тип того, на что он указывает: int*, double*, char*, void* и так далее. Это позволяет компилятору знать, сколько байт занимает целевой объект и как его интерпретировать при разыменовании.
Указатель может находиться в одном из трёх состояний:
- указывает на существующий объект — тогда операция разыменования (
*p) корректна - равен
nullptr(илиNULL, или0в старом коде) — явный признак отсутствия объекта - имеет неопределённое значение — например, после объявления без инициализации; использование такого указателя приводит к неопределённому поведению
Особенность арифметики указателей: при прибавлении целого числа n к указателю p типа T*, результатом будет адрес, смещённый на n * sizeof(T) байт. Это делает указатели естественным инструментом для итерации по массивам и реализации контейнеров.
Указатели не владеют памятью — они лишь ссылаются на неё. Ответственность за выделение (new) и освобождение (delete) памяти лежит на программисте. Именно эта особенность делает указатели одновременно гибкими и опасными: двойное освобождение, использование после освобождения (dangling pointer), утечки памяти — типичные ошибки в C-подобных языках. Современный C++ рекомендует заменять «голые» указатели на умные указатели (std::unique_ptr, std::shared_ptr, std::weak_ptr), которые автоматизируют управление временем жизни объектов и значительно повышают надёжность.
Тем не менее, указатели остаются незаменимы для:
- реализации полиморфизма через виртуальные функции (требуется ссылка или указатель на базовый класс),
- передачи массивов и больших объектов без копирования,
- работы с интерфейсами C и низкоуровневой памятью (например, memory-mapped I/O).
Ссылки
Ссылка — это альтернативное имя для уже существующего объекта. В отличие от указателя, ссылка не является отдельным объектом и не занимает места в памяти (на уровне абстракции; физически компилятор обычно реализует её как указатель, но скрывает это). После инициализации ссылка не может быть «перенаправлена» на другой объект — она пожизненно привязана к тому, на что была заведена.
Объявление и инициализация ссылки:
<тип>& <имя> = <существующая_переменная>;
Использование ссылки:
<имя> = <новое_значение>;
или
<тип> <результат> = <имя>;
Передача ссылки в функцию:
<возвращаемый_тип> <функция>(<тип>& <параметр>);
Возврат ссылки из функции:
<тип>& <функция>();
Ссылка — это псевдоним для уже существующей переменной. После инициализации ссылку нельзя изменить.
#include <iostream>
int main() {
int original = 10;
int& ref = original; // ref — это другое имя для original
std::cout << "Оригинал: " << original << std::endl; // 10
std::cout << "Через ссылку: " << ref << std::endl; // 10
ref = 20; // изменяем значение через ссылку
std::cout << "После изменения: " << original << std::endl; // 20
return 0;
}
Ссылки часто используются в параметрах функций для передачи без копирования:
void increment(int& x) {
x++;
}
int main() {
int n = 5;
increment(n);
std::cout << n << std::endl; // 6
}
Существует два вида ссылок:
- lvalue-ссылки (
T&) — привязываются к именованным объектам (lvalues), например, переменным - rvalue-ссылки (
T&&) — введены в C++11, привязываются к временным объектам (rvalues), например, возвращаемым значениям функций
Семантически ссылка безопаснее указателя: она не может быть нулевой, не требует разыменования и не допускает неявного переопределения цели. Поэтому предпочтительный способ передачи аргументов в функции — по константной ссылке (const T&), если объект не нужно модифицировать и копирование дорого. Это избегает накладных расходов на копирование без рисков, присущих указателям.
Rvalue-ссылки открыли путь для семантики перемещения (move semantics): возможность «перехватить» ресурсы временного объекта вместо их копирования. Это особенно важно для классов, управляющих динамической памятью, файловыми дескрипторами или другими внешними ресурсами. Например, std::vector при возврате из функции может передать владение своим внутренним буфером напрямую, минуя аллокацию и копирование данных.
Важно различать:
T& r = x;— lvalue-ссылка наxT&& rr = std::move(x);— rvalue-ссылка, позволяющая «разобрать»x, если это безопасноconst T& cr = /* что угодно */;— универсальная ссылка, способная привязаться к любому выражению, продлевая время жизни временного объекта
Массивы
Массив — это составной тип данных, который хранит фиксированное количество элементов одного и того же типа в непрерывном блоке памяти. Элементы массива доступны по индексу, начиная с нуля.
Объявление массива фиксированного размера:
<тип> <имя>[<размер>];
Инициализация при объявлении:
<тип> <имя>[<размер>] = {<значение1>, <значение2>, ..., <значениеN>};
Автоматическое определение размера:
<тип> <имя>[] = {<значение1>, <значение2>, ..., <значениеN>};
Доступ к элементам
Чтение или запись по индексу:
<имя>[<индекс>] = <значение>;
<тип> <переменная> = <имя>[<индекс>];
Индекс начинается с нуля и должен быть меньше <размер>.
Объявление двумерного массива:
<тип> <имя>[<строки>][<столбцы>];
Инициализация двумерного массива:
<тип> <имя>[<строки>][<столбцы>] = { {<элементы_строки1>}, {<элементы_строки2>}, ... };
Доступ к элементу:
<имя>[<номер_строки>][<номер_столбца>] = <значение>;
Преобразование имени массива в указатель:
<тип>* <указатель> = <имя_массива>;
Арифметика указателей:
*(<имя_массива> + <смещение>) эквивалентно <имя_массива>[<смещение>]
Передача массива в функцию (через указатель):
<возвращаемый_тип> <функция>(<тип> <параметр>[])
или
<возвращаемый_тип> <функция>(<тип>* <параметр>)
При передаче массив теряет информацию о размере, поэтому размер часто передаётся отдельным параметром:
void process(int arr[], int size);
Вычисление количества элементов:
sizeof(<имя>) / sizeof(<имя>[0])
Этот приём работает только внутри той области видимости, где массив объявлен как статический. Не работает для параметров функций.
Статический массив (размер известен на этапе компиляции)
#include <iostream>
int main() {
// Объявление и инициализация массива из 5 целых чисел
int numbers[5] = {10, 20, 30, 40, 50};
// Доступ к элементам по индексу
std::cout << "Первый элемент: " << numbers[0] << std::endl; // 10
std::cout << "Третий элемент: " << numbers[2] << std::endl; // 30
// Изменение значения элемента
numbers[1] = 25;
std::cout << "Изменённый второй элемент: " << numbers[1] << std::endl; // 25
// Перебор всех элементов
for (int i = 0; i < 5; ++i) {
std::cout << numbers[i] << " ";
}
std::cout << std::endl;
return 0;
}
Можно не указывать размер явно — компилятор определит его по количеству инициализаторов:
double prices[] = {19.99, 5.50, 100.0}; // размер — 3
Многомерный массив
#include <iostream>
int main() {
// Двумерный массив 2×3
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
// Доступ к элементу во второй строке, третьем столбце
std::cout << "Элемент [1][2]: " << matrix[1][2] << std::endl; // 6
// Вывод всей матрицы
for (int i = 0; i < 2; ++i) {
for (int j = 0; j < 3; ++j) {
std::cout << matrix[i][j] << " ";
}
std::cout << std::endl;
}
return 0;
}
Массив символов (C-style строка)
#include <iostream>
int main() {
char greeting[] = "Привет"; // автоматически добавляется завершающий нуль '\0'
std::cout << greeting << std::endl; // Вывод: Привет
// Ручной перебор до нулевого символа
for (int i = 0; greeting[i] != '\0'; ++i) {
std::cout << greeting[i] << "-";
}
std::cout << std::endl;
return 0;
}
Указатель на массив и арифметика указателей
#include <iostream>
int main() {
int data[4] = {7, 14, 21, 28};
int* ptr = data; // имя массива преобразуется в указатель на первый элемент
std::cout << "Через указатель: " << *(ptr + 2) << std::endl; // 21
// Эквивалентность: data[i] == *(data + i)
std::cout << "data[3] == " << data[3] << ", *(data+3) == " << *(data + 3) << std::endl;
return 0;
}
Важно: Статические массивы в C++ имеют фиксированный размер, не могут быть скопированы присваиванием (
arr1 = arr2— ошибка), и их размер нельзя изменить во время выполнения.
Массив — это упорядоченная последовательность элементов одного типа, размещённая в смежных ячейках памяти. Объявление вида int arr[10]; создаёт стековый массив из 10 целых чисел. Размер массива должен быть константным выражением, известным на этапе компиляции — это ограничение делает классические массивы непригодными для случаев, когда размер определяется динамически.
Массивы обладают рядом особенностей, затрудняющих их использование:
- при передаче в функцию они деградируют в указатель:
void f(int a[5])эквивалентноvoid f(int* a) - оператор
sizeofвозвращает общий размер только в том же блоке, где массив объявлен; в функции он вернёт размер указателя - отсутствует встроенная проверка выхода за границы
- невозможность присваивания массивов напрямую:
arr1 = arr2;— ошибка компиляции
Именно поэтому в современном C++ рекомендуется использовать std::array<T, N> — обёртку над статическим массивом из заголовка <array>. Она предоставляет интерфейс, совместимый с STL (методы size(), begin(), end()), поддерживает копирование и присваивание, не деградирует в указатель при передаче по значению и совместима с шаблонными алгоритмами.
Для динамических массивов — когда размер неизвестен заранее — следует использовать std::vector<T>. Он инкапсулирует указатель, счётчик размера и ёмкости, автоматически управляет памятью, предоставляет безопасный доступ и гибкие операции вставки/удаления. Прямое использование new T[N] и delete[] считается устаревшим подходом, за исключением очень специфических сценариев (например, написание собственного аллокатора).
Перечисления (enum)
Перечисление — это пользовательский тип, представляющий набор именованных констант.
Объявление перечисления:
enum <имя> { <константа1>, <константа2>, ... };
Объявление перечисления с явными значениями:
enum <имя> { <константа1> = <значение1>, <константа2> = <значение2>, ... };
Объявление типобезопасного перечисления:
enum class <имя> { <константа1>, <константа2>, ... };
Использование значения перечисления:
<имя> <переменная> = <имя>::<константа>;
Перечисление определяет набор именованных целочисленных констант.
#include <iostream>
enum Color {
RED,
GREEN,
BLUE
};
int main() {
Color background = GREEN;
std::cout << "Цвет: " << background << std::endl; // 1
if (background == GREEN) {
std::cout << "Фон зелёный." << std::endl;
}
return 0;
}
Можно явно задать значения:
enum HttpStatus {
OK = 200,
NOT_FOUND = 404,
INTERNAL_ERROR = 500
};
Для большей типовой безопасности используется enum class:
enum class Direction {
UP, DOWN, LEFT, RIGHT
};
int main() {
Direction d = Direction::UP;
// std::cout << d; // ошибка: нет неявного преобразования в int
return 0;
}
Классический C-стиль (enum) имеет серьёзные недостатки:
- константы «утекают» в окружающую область видимости, что может вызывать коллизии имён
- перечисление не имеет собственного типа — оно неявно конвертируется в целочисленный тип и обратно
- размер и знаковость лежат в ведении реализации
Пример проблемы:
enum Color { Red, Green, Blue };
enum Status { Red, Active }; // ошибка: Red уже объявлен
int x = Red; // допустимо — Red неявно int
В C++11 введены строгие перечисления (enum class или enum struct), которые решают эти проблемы:
enum class Color { Red, Green, Blue };
enum class Status { Red, Active }; // допустимо — пространства имён разные
Color c = Color::Red;
int x = c; // ошибка компиляции — нет неявного преобразования
Строгие перечисления имеют собственный тип, не приводятся к целым без static_cast, и их значения доступны только через квалифицированное имя (Color::Red). По умолчанию базовым типом является int, но его можно явно задать:
enum class Flags : uint8_t { Read = 1, Write = 2, Exec = 4 };
Это особенно полезно при работе с битовыми флагами, поскольку позволяет контролировать размер и обеспечивать переносимость.
Объединения (union)
Объединение позволяет хранить разные типы данных в одном и том же участке памяти, но только одно значение активно в каждый момент времени.
Объявление объединения:
union <имя> {
<тип1> <поле1>;
<тип2> <поле2>;
// ...
};
Создание экземпляра объединения:
<имя> <переменная>;
Доступ к полю объединения:
<переменная>.<поле> = <значение>;
#include <iostream>
union Data {
int i;
float f;
char str[20];
};
int main() {
Data data;
data.i = 10;
std::cout << "Целое: " << data.i << std::endl;
data.f = 220.5f;
std::cout << "Вещественное: " << data.f << std::endl;
// после записи в f, значение i больше не актуально
return 0;
}
Объединения полезны при работе с бинарными данными или когда нужно интерпретировать один и тот же блок памяти по-разному.
Объединение — это специальный агрегатный тип, все члены которого разделяют одну и ту же область памяти. Размер объединения равен размеру его самого большого члена (плюс выравнивание). В каждый момент времени в объединении может быть активен только один член — запись в один член делает значение других неопределённым.
Классическое объединение из C не имеет конструкторов, деструкторов и не отслеживает, какой член активен. Это делает его крайне небезопасным: при разрушении объединения с нетривиальными типами (например, std::string) возникает неопределённое поведение, поскольку деструктор вызывается не для того объекта, который был создан.
В C++11 появилась поддержка объединений с нетривиальными членами, но программист обязан вручную управлять временем жизни объектов с помощью placement new и явного вызова деструктора — и всё равно отслеживать активный член. Из-за сложности и рисков такие объединения используются редко.
На практике вместо «голых» объединений рекомендуется применять типы-объединения из стандартной библиотеки:
std::variant<T1, T2, ...>— типобезопасное объединение, отслеживающее активный альтернативный тип, поддерживающее посещение (std::visit) и выбрасывающее исключение при некорректном доступеstd::optional<T>— частный случай объединения «значение или отсутствие значения», идеален для возврата из функций, где результат может быть не определён
Эти типы инкапсулируют всю сложность управления состоянием и делают код надёжным без потери производительности.
Структуры (struct) и классы (class)
Структуры и классы — основные механизмы пользовательской абстракции в C++. С точки зрения языка, между struct и class существует единственное различие: уровень доступа по умолчанию для членов и базовых классов.
- В
structпо умолчанию —public - В
classпо умолчанию —private
Во всём остальном — синтаксис, поддержка наследования, виртуальных функций, шаблонов, операторов — они идентичны. Это означает, что выбор между struct и class — вопрос стиля и семантики, а не технической возможности.
Общепринятая практика:
structиспользуется для агрегатных типов — простых контейнеров данных без инвариантов, логики и управления ресурсами. Например,Point { int x, y; },Config { std::string host; int port; }. Такие типы часто инициализируются агрегатной инициализацией (Point p{1, 2};) и не требуют конструкторов.classприменяется для инкапсулированных сущностей с инвариантами, поведением, управлением жизненным циклом. Например,FileHandle,DatabaseConnection,ThreadPool.
Структура объединяет несколько переменных разных типов в одну логическую единицу.
Объявление структуры:
struct <имя> {
<тип1> <поле1>;
<тип2> <поле2>;
// ...
<возвращаемый_тип> <метод>(<параметры>);
};
Создание экземпляра структуры:
<имя> <переменная>;
или
<имя> <переменная> = { <значение1>, <значение2>, ... };
Доступ к полю или методу:
<переменная>.<поле> = <значение>;
<переменная>.<метод>(<аргументы>);
#include <iostream>
#include <string>
struct Person {
std::string name;
int age;
};
int main() {
Person p;
p.name = "Анна";
p.age = 30;
std::cout << p.name << ", " << p.age << " лет" << std::endl;
return 0;
}
Структуры могут содержать функции:
struct Point {
double x, y;
double distanceToOrigin() const {
return (x * x + y * y);
}
};
int main() {
Point p{3.0, 4.0};
std::cout << "Квадрат расстояния до начала координат: " << p.distanceToOrigin() << std::endl;
}
Класс — это расширение структуры с поддержкой инкапсуляции, наследования и полиморфизма. По умолчанию члены класса приватны.
Объявление класса:
class <имя> {
private:
<тип> <приватное_поле>;
public:
<имя>(<параметры_конструктора>);
<возвращаемый_тип> <публичный_метод>(<параметры>);
<тип> <геттер>() const;
void <сеттер>(<тип> <значение>);
};
Определение конструктора вне класса:
<имя>::<имя>(<параметры>) : <поле1>(<значение1>), ... { /* тело */ }
Создание объекта класса:
<имя> <объект>(<аргументы_конструктора>);
Вызов метода объекта:
<объект>.<метод>(<аргументы>);
Доступ к свойству через геттер/сеттер:
<объект>.<геттер>();
<объект>.<сеттер>(<значение>);
#include <iostream>
#include <string>
class BankAccount {
private:
std::string owner;
double balance;
public:
BankAccount(const std::string& name, double initialBalance)
: owner(name), balance(initialBalance) {}
void deposit(double amount) {
if (amount > 0) balance += amount;
}
bool withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return true;
}
return false;
}
double getBalance() const {
return balance;
}
std::string getOwner() const {
return owner;
}
};
int main() {
BankAccount acc("Иван", 1000.0);
acc.deposit(500.0);
acc.withdraw(200.0);
std::cout << acc.getOwner() << " — баланс: " << acc.getBalance() << std::endl;
return 0;
}
Классы являются основой объектно-ориентированного программирования в C++.
Современный C++ поощряет value semantics: классы, ведущие себя как встроенные типы — поддерживающие копирование, перемещение, сравнение. Для этого следует явно определять или использовать правила «большой пятерки»: конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением, деструктор — или полагаться на автоматическую генерацию, если это безопасно.
Важно: если класс управляет ресурсом (памятью, файлом, сокетом), он должен следовать правилу трёх/пяти и правилу нуля:
- Правило трёх: если определён хотя бы один из деструктора, копирующего конструктора или копирующего присваивания — скорее всего, нужно определить все три
- Правило пяти: в C++11 добавляются перемещающие операции
- Правило нуля: лучше вообще не определять ни одной из них, если можно делегировать управление ресурсом другим объектам (например,
std::unique_ptr,std::vector) — тогда компилятор сгенерирует безопасные версии автоматически
Это позволяет создавать классы, которые легко использовать, сложно неправильно использовать и которые гармонично вписываются в экосистему STL.
Взаимосвязь типов: композиция, наследование, агрегация
Производные типы редко существуют изолированно. Они взаимодействуют через:
- агрегацию — один тип содержит экземпляр другого (например,
struct Person { Address home; }) - композицию — более тесная форма агрегации, где часть не существует без целого
- наследование —
class Derived : public Base— механизм для расширения интерфейса и реализации полиморфизма - параметризацию —
template<typename T> class Stack { … };— обобщение по типу
Особую роль играет полиморфизм: возможность обращаться с объектами разных типов через единый интерфейс. В C++ он реализуется через виртуальные функции и требует использования указателей или ссылок на базовый класс. Абстрактные базовые классы (с чисто виртуальными функциями, = 0) задают контракт, который обязаны реализовать наследники.
Современные тенденции всё чаще предпочитают полиморфизм на основе понятий (concepts, C++20) и типов-обёрток (std::function, std::any) вместо иерархий наследования, особенно когда речь идёт о гибкости и компиляции в виде заголовков. Однако наследование остаётся незаменимым в системах с плагинами, фреймворками и объектными моделями.
А строки?
В языке C++ нет встроенного строкового типа на уровне ядра языка, как, например, int или bool. Однако это не означает отсутствия строк как таковых — они реализованы на уровне стандартной библиотеки.
Строки в C++ — это составной тип данных, предназначенный для хранения и обработки последовательностей символов. В языке существует несколько способов представления строк: через массивы символов (C-style строки) и через класс std::string из стандартной библиотеки. Ниже приведены простые примеры и алгоритмические шаблоны для работы со строками.
Строка в C++ — это либо:
- массив символов с завершающим нулём (
\0), известный как C-style строка (унаследован из языка Си); - либо объект класса
std::string(илиstd::wstring,std::u8stringи др.), предоставляемый стандартной библиотекой C++ и инкапсулирующий управление памятью, длину, операции сравнения, конкатенации и другие функции.
C-style строки (массивы символов)
C-style строки — это массивы типа char, завершающиеся нулевым символом \0. Они наследуются из языка Си и широко используются в системном программировании.
C-style строки:
- Это одномерный массив типа
char(илиwchar_tдля широких символов). - Последний элемент всегда содержит нулевой символ
\0, сигнализирующий конец строки. - Управление памятью полностью лежит на программисте.
- Операции выполняются через функции из
<cstring>:strlen,strcpy,strcat,strcmp.
Пример:
char text[] = "Привет";
Здесь компилятор автоматически добавляет \0 в конец.
Объявление строки фиксированного размера:
char <имя>[<размер>];
Инициализация строкового литерала:
char <имя>[] = "<текст>";
Копирование строки:
strcpy(<цель>, <источник>);
Конкатенация строк:
strcat(<цель>, <добавляемая_строка>);
Определение длины строки:
strlen(<имя>);
Сравнение строк:
strcmp(<строка1>, <строка2>);
— возвращает 0, если строки равны; отрицательное число, если первая меньше; положительное — если больше.
Чтение строки из потока (без переполнения):
fgets(<буфер>, <макс_длина>, stdin);
#include <iostream>
#include <cstring> // для strlen, strcpy и других функций
int main() {
// Объявление и инициализация C-style строки
char greeting[] = "Привет";
// Вывод строки
std::cout << greeting << std::endl;
// Длина строки
std::cout << "Длина: " << strlen(greeting) << std::endl;
// Копирование строки
char copy[20];
strcpy(copy, greeting);
std::cout << "Копия: " << copy << std::endl;
// Конкатенация
char message[50] = "Сообщение: ";
strcat(message, greeting);
std::cout << message << std::endl;
return 0;
}
Важно: при работе с C-style строками необходимо самостоятельно следить за размером буфера, чтобы избежать переполнения.
Строки через std::string
Класс std::string предоставляет удобный и безопасный интерфейс для работы со строками. Он автоматически управляет памятью и поддерживает множество операций.
std::string:
- Это шаблонный класс
std::basic_string<char>, определённый в заголовке<string>. - Хранит символы в динамически выделенной памяти.
- Автоматически управляет размером, поддерживает безопасное изменение длины.
- Поддерживает операторы (
+,==,[]), методы (length(),substr(),find()и т.д.). - Гарантирует непрерывность хранения символов (начиная с C++11).
Пример:
#include <string>
std::string message = "Здравствуйте";
Класс std::string предоставляет интерфейс, близкий к поведению фундаментального типа: его можно копировать, присваивать, передавать в функции без ручного управления памятью.
Подключение заголовка:
#include <string>
Объявление строки:
std::string <имя>;
Инициализация строки:
std::string <имя> = "<текст>";
или
std::string <имя>("<текст>");
Присваивание значения:
<имя> = "<новый_текст>";
Конкатенация строк:
<результат> = <строка1> + <строка2>;
или
<строка1> += <строка2>;
Получение длины строки:
<имя>.length() или <имя>.size()
Доступ к символу по индексу:
<имя>[<индекс>]
(индексация начинается с 0)
Изменение символа:
<имя>[<индекс>] = '<новый_символ>';
Поиск подстроки:
<имя>.find("<подстрока>");
— возвращает позицию или std::string::npos, если не найдено.
Извлечение подстроки:
<имя>.substr(<начало>, <длина>);
Чтение строки из стандартного ввода (включая пробелы):
std::getline(std::cin, <имя>);
Сравнение строк:
if (<строка1> == <строка2>) { /* ... */ }
Поддерживаются все операторы сравнения: ==, !=, <, >, <=, >=.
#include <iostream>
#include <string>
int main() {
// Объявление и инициализация
std::string text = "Здравствуйте";
std::string name("Мир");
// Вывод
std::cout << text << ", " << name << "!" << std::endl;
// Длина строки
std::cout << "Длина: " << text.length() << std::endl;
// Конкатенация
std::string full = text + ", " + name + "!";
std::cout << full << std::endl;
// Доступ к символу по индексу
std::cout << "Первый символ: " << text[0] << std::endl;
// Изменение символа
text[0] = 'з'; // теперь "здравствуйте" (строчная буква)
std::cout << text << std::endl;
// Поиск подстроки
size_t pos = full.find("Мир");
if (pos != std::string::npos) {
std::cout << "Найдено на позиции: " << pos << std::endl;
}
return 0;
}
Чтение строк из ввода
#include <iostream>
#include <string>
int main() {
std::string input;
std::cout << "Введите строку: ";
std::getline(std::cin, input); // читает всю строку, включая пробелы
std::cout << "Вы ввели: " << input << std::endl;
return 0;
}
Практические примеры: типы данных в действии
Прежде чем перейти к продвинутым механизмам, полезно рассмотреть, как типы данных комбинируются в реальном коде. Ниже приведён фрагмент, иллюстрирующий типичную «плотность» типовой системы C++ в контексте прикладной задачи — описания конфигурации сетевой службы.
#include <string>
#include <vector>
#include <chrono>
#include <optional>
#include <cstdint>
// Строгое перечисление — безопасное именование режимов
enum class Protocol : uint8_t {
HTTP,
HTTPS,
WebSocket
};
// Агрегатная структура: данные без логики, инициализируется списком
struct Endpoint {
std::string host;
uint16_t port;
Protocol proto;
};
// Класс с инкапсуляцией и управлением состоянием
class ServiceConfig {
std::string name_;
std::vector<Endpoint> endpoints_;
std::chrono::seconds timeout_;
mutable std::optional<size_t> hash_cache_; // кэш хеша — изменяем в const-методах
public:
// Конструктор с проверкой инварианта
ServiceConfig(
std::string name,
std::vector<Endpoint> endpoints,
int timeout_sec
)
: name_(std::move(name))
, endpoints_(std::move(endpoints))
, timeout_(std::chrono::seconds{timeout_sec})
{
if (name_.empty()) throw std::invalid_argument("name must not be empty");
if (endpoints_.empty()) throw std::invalid_argument("at least one endpoint required");
if (timeout_sec <= 0) throw std::invalid_argument("timeout must be positive");
}
// Доступ только для чтения через константные ссылки
const std::string& name() const { return name_; }
const std::vector<Endpoint>& endpoints() const { return endpoints_; }
std::chrono::seconds timeout() const { return timeout_; }
// Хеширование с кэшированием — mutable позволяет модифицировать cache в const-контексте
size_t hash() const {
if (!hash_cache_) {
size_t h = std::hash<std::string>{}(name_);
for (const auto& ep : endpoints_) {
// Простой комбинированный хеш
h ^= std::hash<std::string>{}(ep.host) + 0x9e3779b9
^ (ep.port << 16)
^ static_cast<size_t>(ep.proto);
}
hash_cache_ = h;
}
return *hash_cache_;
}
// Оператор равенства — значение-ориентированное сравнение
bool operator==(const ServiceConfig& other) const {
return name_ == other.name_
&& endpoints_ == other.endpoints_
&& timeout_ == other.timeout_;
}
};
Этот пример демонстрирует несколько ключевых идей:
enum classобеспечивает типобезопасность и изоляцию имён.std::vector<Endpoint>заменяет сырой массив, обеспечивая безопасность границ и автоматическое управление памятью.std::chrono::seconds— типизированное представление временного интервала, исключающее путаницу между секундами, миллисекундами и тактовыми циклами.std::optional<size_t>явно выражает «значение может отсутствовать», избегая «магических» значений вроде-1или0.mutableпозволяет кэшировать вычисления без нарушения логической константности интерфейса.std::moveв инициализаторе обеспечивает эффективную передачу владения строками и векторами.- Инварианты проверяются в конструкторе — объект создаётся только в корректном состоянии.
Важно: этот код не использует new, delete, голые указатели, C-строки или неявные преобразования. Он опирается на value semantics, RAII и типобезопасные абстракции — именно так строится современный C++.
Пользовательские литералы
Пользовательские литералы — механизм, введённый в C++11, позволяющий расширять синтаксис языка за счёт определения собственных суффиксов для литералов. Это повышает выразительность и снижает вероятность ошибок, связанных с единицами измерения или кодировками.
Синтаксически пользовательский литерал — это операторная функция с именем вида operator"" _suffix. Различают несколько категорий в зависимости от типа входного литерала:
- Целочисленные литералы:
operator"" _km(unsigned long long) - Вещественные литералы:
operator"" _s(long double) - Строковые литералы:
operator"" _raw(const char*, size_t)— устаревший, заменён на шаблонную версию в C++20 - Шаблонные строковые литералы (C++20):
template<char...> auto operator"" _sym()— позволяет анализировать строку на этапе компиляции
Пример: определение единиц времени и расстояния.
#include <chrono>
// 5_min → std::chrono::minutes{5}
constexpr std::chrono::minutes operator"" _min(unsigned long long m) {
return std::chrono::minutes{m};
}
// 3.5_s → std::chrono::duration<long double, std::ratio<1>>{3.5}
constexpr auto operator"" _s(long double s) {
return std::chrono::duration<long double>{s};
}
// 100_km → целое количество метров (для вычислений без плавающей точки)
constexpr long long operator"" _km(unsigned long long km) {
return km * 1000;
}
// Использование:
auto delay = 2_min + 0.5_s; // std::chrono::duration<double>
auto distance = 5_km + 300; // 5300 (метров)
auto flight_time = distance / 250.0; // без единиц — ошибка дизайна!
Обратите внимание: последняя строка показывает ограничение — пользовательские литералы не создают размерных типов, а лишь удобный способ конструирования. Для настоящей размерной системы (где km / hour даёт km/h) нужны более сложные шаблонные конструкции (например, Boost.Units или mp-units в C++23/C++26). Тем не менее, даже простые литералы резко повышают читаемость:
sleep_for(5000) → sleep_for(5_s) — разница между «магическим числом» и выразительным кодом.
Важно: пользовательские литералы должны начинаться с подчёркивания (например, _s, _kg). Имена без подчёркивания зарезервированы за стандартной библиотекой.
Псевдонимы типов: typedef и using
Псевдонимы типов позволяют вводить альтернативные имена для существующих типов. Это упрощает чтение, рефакторинг и обобщённое программирование.
До C++11 использовался только typedef, унаследованный от C. Его синтаксис нелогичен при работе с указателями и шаблонами:
typedef int* IntPtr; // OK
typedef void (*FuncPtr)(); // указатель на функцию — уже сложно
typedef std::map<std::string, std::vector<int>> StringIntMap; // громоздко
C++11 ввёл унифицированный синтаксис через using, который читается как «это есть то»:
using IntPtr = int*;
using FuncPtr = void(*)();
using StringIntMap = std::map<std::string, std::vector<int>>;
Преимущества using:
- единообразие:
Alias = Type - поддержка шаблонных псевдонимов — невозможна с
typedef:
template<typename T>
using Vec = std::vector<T, CustomAllocator<T>>;
Vec<int> v; // эквивалентно std::vector<int, CustomAllocator<int>>
- совместимость с
decltype,auto,constexpr-контекстами.
Шаблонные псевдонимы особенно полезны в метапрограммировании: они позволяют избегать многословных typename T::template Nested<U>::type, заменяя их на лаконичные alias<T, U>.
Пример из стандартной библиотеки — std::byte, введённый в C++17:
using byte = unsigned char;
Этот псевдоним ничего не меняет на уровне машинного кода, но семантически отделяет «байт данных» от «символа», запрещая арифметику (поскольку std::byte имеет свой набор операторов) и повышая безопасность при работе с сырой памятью.
Type Traits: рефлексия на этапе компиляции
Type traits — набор шаблонных классов в заголовке <type_traits>, предоставляющих информацию о свойствах типов на этапе компиляции. Они лежат в основе обобщённого программирования, позволяя писать код, адаптирующийся под возможности типа без его явного знания.
Каждый trait — это шаблон, инстанцируемый типом, и предоставляющий static constexpr члены (обычно value) или вложенные типы (type). Примеры:
std::is_integral_v<T>—true, еслиT— целочисленный типstd::is_same_v<T, U>— проверка на идентичность типовstd::remove_const_t<T>— убираетconst, например,const int→intstd::decay_t<T>— имитирует «деградацию» при передаче аргумента по значению (массив → указатель, ссылка → тип, функция → указатель на функцию)std::enable_if_t<B, T>— условный тип: еслиBистинно, даётT, иначе — ошибка подстановки (используется в SFINAE)
Type traits позволяют реализовывать условную компиляцию без макросов и if на этапе выполнения. Пример: функция, принимающая только арифметические типы:
#include <type_traits>
template<typename T>
auto square(T x) -> std::enable_if_t<std::is_arithmetic_v<T>, T> {
return x * x;
}
Если T — не арифметический тип, подстановка шаблона завершится неудачей, и перегрузка будет отброшена (см. SFINAE ниже).
Современный подход — использовать if constexpr (C++17) внутри шаблонных функций, что делает код чище:
template<typename T>
T process(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else if constexpr (std::is_floating_point_v<T>) {
return value * 1.5;
} else {
static_assert(sizeof(T) == 0, "Unsupported type");
}
}
static_assert с sizeof(T) == 0 гарантирует ошибку компиляции для необработанных типов, и сообщение будет понятным.
SFINAE: Substitution Failure Is Not An Error
SFINAE — фундаментальный принцип разрешения перегрузок шаблонов в C++. Он гласит: если подстановка аргументов шаблона в сигнатуру функции приводит к некорректному коду (например, несуществующему вложенному типу), это не ошибка компиляции — такая перегрузка просто исключается из рассмотрения.
Этот механизм позволяет писать «условные» перегрузки, активные только при выполнении определённых свойств типа.
Классический пример — проверка наличия метода serialize():
// Вспомогательный trait: имеет ли T метод void serialize() const?
template<typename T, typename = void>
struct has_serialize : std::false_type {};
template<typename T>
struct has_serialize<T, std::void_t<decltype(std::declval<const T&>().serialize())>>
: std::true_type {};
// Перегрузки:
template<typename T>
std::enable_if_t<has_serialize_v<T>> save(const T& obj) {
obj.serialize(); // безопасно — перегрузка доступна только если serialize существует
}
template<typename T>
std::enable_if_t<!has_serialize_v<T>> save(const T& obj) {
// fallback: например, бинарная запись
write_bytes(reinterpret_cast<const char*>(&obj), sizeof(obj));
}
Здесь std::void_t (C++17) — удобная обёртка, превращающая любое корректное выражение в void. Если obj.serialize() не вызываемо, подстановка во вторую специализацию has_serialize провалится, и будет выбрана базовая — false_type.
SFINAE — мощный, но хрупкий инструмент. Ошибки в trait’ах трудно отлаживать (сообщения компилятора многословны), а код быстро становится непрозрачным. Поэтому в C++20 ему на смену пришли концепты.
Концепты (Concepts, C++20)
Концепты — декларативный механизм ограничения шаблонных параметров. Они позволяют явно выразить требования к типу: «этот тип должен быть регулярным», «он должен поддерживать оператор <», «он должен быть контейнером».
Синтаксис:
template<std::integral T>
T add(T a, T b) {
return a + b;
}
или
template<typename T>
requires std::integral<T>
T add(T a, T b) {
return a + b;
}
Стандартная библиотека предоставляет множество концептов:
std::integral,std::floating_point— категории арифметических типовstd::equality_comparable,std::totally_ordered— требования к операторам сравненияstd::copyable,std::movable,std::semiregular,std::regular— модели поведения объектовstd::input_iterator,std::random_access_range— для алгоритмов и контейнеров
Пример: функция, работающая с любым диапазоном, элементы которого можно сравнить:
#include <concepts>
#include <ranges>
template<std::ranges::input_range R>
requires std::equality_comparable<std::ranges::range_value_t<R>>
bool contains(const R& range, const std::ranges::range_value_t<R>& value) {
for (const auto& elem : range) {
if (elem == value) return true;
}
return false;
}
Преимущества концептов:
- понятные ошибки компиляции: вместо «подстановка шаблона не удалась в 23 уровнях вложенности» — «тип
MyClassне удовлетворяет концептуstd::integral» - перегрузка по концептам: несколько шаблонов с разными
requires— компилятор выберет наиболее специфичный - документирование интерфейса: требования становятся частью сигнатуры
Концепты не влияют на производительность — вся проверка происходит на этапе компиляции. Они делают обобщённое программирование доступным не только экспертам, но и широкому кругу разработчиков.
Модели памяти и представление типов
Знание того, как типы представлены в памяти, необходимо для написания эффективного, переносимого и безопасного кода — особенно в системном программировании, сериализации, взаимодействии с оборудованием и межъязыковых интерфейсах.
Выравнивание (alignment)
Каждый тип имеет требование к выравниванию — адрес объекта этого типа должен быть кратен некоторому числу байт (обычно степени двойки). Это связано с особенностями архитектуры: процессоры могут читать 4-байтное int эффективно только по адресу, кратному 4.
Стандарт гарантирует:
alignof(char) == 1alignof(T)делитsizeof(T)- для агрегатов выравнивание — максимум из выравниваний членов
Пример:
struct A { char c; int i; }; // sizeof(A) == 8 (1 + 3 padding + 4)
struct B { int i; char c; }; // sizeof(B) == 8 (4 + 1 + 3 padding)
struct C { char c1; char c2; }; // sizeof(C) == 2 — без padding
Управление выравниванием:
alignas(N)— указывает минимальное выравнивание:alignas(64) char buffer[1024];для SIMDstd::aligned_storage,std::aligned_union— устарели в C++23 в пользуalignasи placement new#pragma pack— компиляторно-зависимый способ уменьшить padding (используется в сериализации, но нарушает стандартное выравнивание — осторожно!)
Strict Aliasing Rule
Правило строгого псевдонима запрещает обращаться к объекту через указатель или ссылку на несовместимый тип. Исключение — char* и unsigned char*, через которые можно читать любой объект (для сериализации и отладки).
Некорректно:
int x = 0x12345678;
float f = *reinterpret_cast<float*>(&x); // нарушение strict aliasing
Корректно:
int x = 0x12345678;
std::memcpy(&f, &x, sizeof(f)); // безопасно — memcpy освобождён от этого правила
Нарушение strict aliasing приводит к неопределённому поведению: компилятор может «не увидеть» изменение значения и оптимизировать код некорректно.
Object Model и жизненный цикл
В C++ объект создаётся инициализацией. Этапы:
- Выделение памяти (
operator new, стек, статическая область) - Инициализация — вызов конструктора (или агрегатная инициализация) → объект существует
- Использование
- Разрушение — вызов деструктора
- Освобождение памяти (
operator delete)
До вызова конструктора и после вызова деструктора память занята, но объекта в ней нет. Попытка вызвать нестатический метод на такой памяти — неопределённое поведение.
Placement new позволяет отделить шаги 1 и 2:
alignas(MyClass) char buffer[sizeof(MyClass)];
MyClass* obj = new (buffer) MyClass(); // конструирование в буфере
obj->~MyClass(); // разрушение вручную
// память не освобождается — buffer на стеке
Этот механизм лежит в основе всех контейнеров (std::vector, std::optional) и аллокаторов.